Golang
寫測試程式時,只需在程式名稱後面加上 _test並與程式放在同一個folder
:
例如程式名稱叫 account.go
只要再加上一隻 account_test.go
Package的名稱需要與被測試的程式碼相同
account.sql.go
package db
import (
"context"
)
const addAccountBalance = `-- name: AddAccountBalance :one
UPDATE accounts
SET balance = balance + $1
WHERE id = $2
RETURNING id, owner, balance, currency, created_at
`
...
account_test.go
import (
"context"
"database/sql"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/techschool/simplebank/util"
)
...
camel case
testing.T
object as inputfunc TestCreateAccount(t *testing.T) {
createRandomAccount(t)
}
測試Database CRUD需要實際操作PostgresSQL,所以需要在測試時建立連線
main_test.go
_
“ : import package to init but not use package functionTestMain
是一個特殊的函數,它允許你在運行測試之前和之後進行自訂的設定和清理。TestMain
函數在任何測試函數之前運行,且只運行一次。TestMain
**TestMain
提供了一個設定和初始化Database環境的機會package db
import (
"database/sql"
"log"
"os"
"testing"
_ "github.com/lib/pq"
)
const (
// 定義資料庫的驅動名稱和資料源字符串
dbDriver = "postgres"
dbSource = "postgresql://root:secret@localhost:5432/simple_bank?sslmode=disable"
)
// testQueries 是我們在測試中將使用的 *Queries 實例
var testQueries *Queries
// TestMain 是測試的主入口點。
// 它提供了一個設定和初始化database環境的機會。
func TestMain(m *testing.M) {
// 嘗試連接到指定的資料庫
conn, err := sql.Open(dbDriver, dbSource)
if err != nil {
// 如果連接失敗,將錯誤記錄到日誌並退出
log.Fatal("cannot connect to db: ", err)
}
// 使用已建立的連接初始化 testQueries
testQueries = New(conn)
// m.Run() 會執行所有定義的測試函數。
// 然後,它返回一個代表測試結果的狀態碼。
// os.Exit() 用該狀態碼終止進程。
os.Exit(m.Run())
}
TestMain
函數會被呼叫。db.go
的 New
函數來建立一個新的 Queries
實例,這實際上是使用 db.go
中的功能。go test
時,Go 測試工具會識別並執行所有以 _test.go
結尾的文件中的測試函數。但這只是關於哪些函數會被視為測試函數並執行的規則。這不代表這些測試函數不能或不會與其他非 _test.go
的代碼文件互動。_test.go
文件中的測試函數可以調用、互動並測試同一套件下的其他任何代碼,無論這些代碼是否在 _test.go
文件中。os.Exit(m.Run())
的目的是返回適當的退出碼以基於測試的結果。如果任何測試失敗,m.Run()
將返回非零的狀態碼,這意味著測試套件沒有成功通過。如果所有測試都成功,則返回0。os.Exit
,測試仍然會執行,但不會根據測試結果返回特定的退出碼。對於大多數情況,這可能沒問題,特別是如果您僅在本地運行測試並查看結果。但在 CI/CD 系統或其他需要關注測試結果狀態碼的情況下,正確的退出碼很重要。os.Exit
時,測試仍然運行,但測試結果的退出碼可能無法正確傳遞出去。m *testing.M
是什麼呢?
m.Run()
時,Go 會開始執行所有名稱前綴為 Test
的測試函數,且每個這樣的函數都會收到一個 *testing.T
參數。init
函數是一個特殊的函數,它會在包(package)被載入和初始化時自動執行,而且它不需要被明確地調用。rand.Seed(time.Now().UnixNano())
: 這行程式碼是在設置隨機數的種子值。在計算機程式中,真正的隨機數是很難產生的,因此大多數的隨機數都是所謂的偽隨機數,它們是基於一個種子值(seed value)進行計算的。為了每次程式運行時都能產生不同的隨機序列,我們通常使用當前時間(到納秒)作為種子值。這就是 time.Now().UnixNano()
的作用, 不這麼做的話,每次程序運行時都會產生相同的。package util
import (
"math/rand"
"strings"
"time"
)
const alphabet = "abcdefghijklmnopqrstuvwxyz"
func init() {
rand.Seed(time.Now().UnixNano())
}
// rand.Int63n(n) returns, as an int64, a non-negative pseudo-random number -> 0~n-1.
// 0+min <= n <= max-min+1-1+min -> min <= n <=max
// RandomInt generates a random integer between min and max.
func RandomInt(min, max int64) int64 {
return min + rand.Int63n(max-min+1)
}
// RandomString generates a random string of length n
func RandomString(n int) string {
var sb strings.Builder
alphabetLen := len(alphabet)
for i := 0; i < n; i++ {
// rand.Intn(n) returns, as an int, a non-negative pseudo-random number -> 0~n-1.
randomIndex := rand.Intn(alphabetLen)
// randomChar get a random character from alphabet by randomIndex.
randomChar := alphabet[randomIndex]
sb.WriteByte(randomChar)
}
return sb.String()
}
// RandomOwner generates a random owner name from alphabet of length 6
func RandomOwner() string {
return RandomString(6)
}
// RandomMoney generates a random amount of money
func RandomMoney() int64 {
return RandomInt(0, 1000)
}
// RandomCurrency generates a random currency code
func RandomCurrency() string {
currencies := []string{"USD", "EUR", "CAD"}
n := len(currencies)
// rand.Intn(n) returns, as an int, a non-negative pseudo-random number -> 0~n-1.
return currencies[rand.Intn(n)]
}
Golang
中會使用testify
package
來驗証執行結果是否符合預期testify
https://github.com/stretchr/testify
go get github.com/stretchr/testify
透過testify
驗証DB
與func return
的結果並進行比對
未來需要優化test data
使其可以隨機產生,可以避免UT之間的conflict且codebase較好維護(不需要一個一個修改)
Version 1 :
func TestCreateAccount(t *testing.T) {
arg := CreateAccountParams{
Owner: "tom",
Balance: 10,
Currency: "USD",
}
// testQueries is declared in the main_test
// CreateAccount shouldn't return err and return account not empty
// account's data should equal arg
// account's ID and CreatedAt shouldn't be zero
account, err := testQueries.CreateAccount(context.Background(), arg)
require.NoError(t, err)
require.NotEmpty(t, account)
require.Equal(t, arg.Owner, account.Owner)
require.Equal(t, arg.Balance, account.Balance)
require.Equal(t, arg.Currency, account.Currency)
require.NotZero(t, account.ID)
require.NotZero(t, account.CreatedAt)
}
Version 2 (Random test data):
func TestCreateAccount(t *testing.T) {
arg := CreateAccountParams{
Owner: util.RandomOwner(),
Balance: util.RandomMoney(),
Currency: util.RandomCurrency(),
}
account, err := testQueries.CreateAccount(context.Background(), arg)
require.NoError(t, err)
require.NotEmpty(t, account)
require.Equal(t, arg.Owner, account.Owner)
require.Equal(t, arg.Balance, account.Balance)
require.Equal(t, arg.Currency, account.Currency)
require.NotZero(t, account.ID)
require.NotZero(t, account.CreatedAt)
}
執行package tests
後會告知結果
與目前的coverage
ok github.com/Kcih4518/simple-bank/db/sqlc 0.159s coverage: 6.5% of statements
Account剩餘的UT(Read
、Update
、Delete
) 在執行前都要執行Create
,目前的設計會使得dependence 很高
,且未來一更動TestCreateAccount
就會影響到所有UT,所以需要將CreateAccount
的功能進行解耦。
func createRandomAccount(t *testing.T) Account {
arg := CreateAccountParams{
Owner: util.RandomOwner(),
Balance: util.RandomMoney(),
Currency: util.RandomCurrency(),
}
// testQueries is declared in the main_test
// CreateAccount shouldn't return err and return account not empty
// account's data should equal arg
// account's ID and CreatedAt shouldn't be zero
account, err := testQueries.CreateAccount(context.Background(), arg)
require.NoError(t, err)
require.NotEmpty(t, account)
require.Equal(t, arg.Owner, account.Owner)
require.Equal(t, arg.Balance, account.Balance)
require.Equal(t, arg.Currency, account.Currency)
require.NotZero(t, account.ID)
require.NotZero(t, account.CreatedAt)
return account
}
func TestCreateAccount(t *testing.T) {
createRandomAccount(t)
}
func TestUpdateAccount(t *testing.T) {
account1 := createRandomAccount(t)
arg := UpdateAccountParams{
ID: account1.ID,
Balance: util.RandomMoney(),
}
account2, err := testQueries.UpdateAccount(context.Background(), arg)
require.NoError(t, err)
require.NotEmpty(t, account2)
require.Equal(t, account1.ID, account2.ID)
require.Equal(t, account1.Owner, account2.Owner)
require.Equal(t, arg.Balance, account2.Balance)
require.Equal(t, account1.Currency, account2.Currency)
require.WithinDuration(t, account1.CreatedAt, account2.CreatedAt, time.Second)
}
func TestDeleteAccount(t *testing.T) {
account1 := createRandomAccount(t)
err := testQueries.DeleteAccount(context.Background(), account1.ID)
require.NoError(t, err)
account2, err := testQueries.GetAccount(context.Background(), account1.ID)
require.Error(t, err)
require.Empty(t, account2)
require.EqualError(t, err, sql.ErrNoRows.Error())
}
func TestListAccounts(t *testing.T) {
for i := 0; i < 10; i++ {
createRandomAccount(t)
}
// Limit : the maximum number of rows to return
// Offset : the number of rows to skip before starting to return rows from the query
arg := ListAccountsParams{
Limit: 5,
Offset: 5,
}
accounts, err := testQueries.ListAccounts(context.Background(), arg)
require.NoError(t, err)
require.Len(t, accounts, 5)
for _, account := range accounts {
require.NotEmpty(t, account)
}
}
package db
import (
"context"
"testing"
"time"
"github.com/Kcih4518/simpleBank_2023/util"
"github.com/stretchr/testify/require"
)
func createRandomEntry(t *testing.T, account Account) Entry {
arg := CreateEntryParams{
AccountID: account.ID,
Amount: util.RandomMoney(),
}
entry, err := testQueries.CreateEntry(context.Background(), arg)
require.NoError(t, err)
require.NotEmpty(t, entry)
require.Equal(t, arg.AccountID, entry.AccountID)
require.Equal(t, arg.Amount, entry.Amount)
require.NotZero(t, entry.ID)
require.NotZero(t, entry.CreatedAt)
return entry
}
// You can call createRandomAccount() in all package db
func TestCreateEntry(t *testing.T) {
account := createRandomAccount(t)
createRandomEntry(t, account)
}
func TestGetEnry(t *testing.T) {
account := createRandomAccount(t)
entry1 := createRandomEntry(t, account)
entry2, err := testQueries.GetEntry(context.Background(), entry1.ID)
require.NoError(t, err)
require.NotEmpty(t, entry2)
require.Equal(t, entry1.ID, entry2.ID)
require.Equal(t, entry1.AccountID, entry2.AccountID)
require.Equal(t, entry1.Amount, entry2.Amount)
require.WithinDuration(t, entry1.CreatedAt, entry2.CreatedAt, time.Second)
}
func TestListEntries(t *testing.T) {
account := createRandomAccount(t)
for i := 0; i < 10; i++ {
createRandomEntry(t, account)
}
arg := ListEntriesParams{
AccountID: account.ID,
Limit: 5,
Offset: 5,
}
entries, err := testQueries.ListEntries(context.Background(), arg)
require.NoError(t, err)
require.Len(t, entries, 5)
for _, entry := range entries {
require.NotEmpty(t, entry)
require.Equal(t, arg.AccountID, entry.AccountID)
}
}
package db
import (
"context"
"testing"
"time"
"github.com/Kcih4518/simpleBank_2023/util"
"github.com/stretchr/testify/require"
)
func createRandomTransfer(t *testing.T, account1, account2 Account) Transfer {
arg := CreateTransferParams{
FromAccountID: account1.ID,
ToAccountID: account2.ID,
Amount: util.RandomMoney(),
}
transfer, err := testQueries.CreateTransfer(context.Background(), arg)
require.NoError(t, err)
require.NotEmpty(t, transfer)
require.Equal(t, arg.FromAccountID, transfer.FromAccountID)
require.Equal(t, arg.ToAccountID, transfer.ToAccountID)
require.Equal(t, arg.Amount, transfer.Amount)
require.NotZero(t, transfer.ID)
require.NotZero(t, transfer.CreatedAt)
return transfer
}
func TestCreateTransfer(t *testing.T) {
account1 := createRandomAccount(t)
account2 := createRandomAccount(t)
createRandomTransfer(t, account1, account2)
}
func TestGetTransfer(t *testing.T) {
account1 := createRandomAccount(t)
account2 := createRandomAccount(t)
transfer1 := createRandomTransfer(t, account1, account2)
transfer2, err := testQueries.GetTransfer(context.Background(), transfer1.ID)
require.NoError(t, err)
require.NotEmpty(t, transfer2)
require.Equal(t, transfer1.ID, transfer2.ID)
require.Equal(t, transfer1.FromAccountID, transfer2.FromAccountID)
require.Equal(t, transfer1.ToAccountID, transfer2.ToAccountID)
require.Equal(t, transfer1.Amount, transfer2.Amount)
require.WithinDuration(t, transfer1.CreatedAt, transfer2.CreatedAt, time.Second)
}
func TestListTransfer(t *testing.T) {
account1 := createRandomAccount(t)
account2 := createRandomAccount(t)
for i := 0; i < 10; i++ {
createRandomTransfer(t, account1, account2)
}
arg := ListTransfersParams{
FromAccountID: account1.ID,
ToAccountID: account2.ID,
Limit: 5,
Offset: 5,
}
transfers, err := testQueries.ListTransfers(context.Background(), arg)
require.NoError(t, err)
require.Len(t, transfers, 5)
for _, transfer := range transfers {
require.NotEmpty(t, transfer)
require.Equal(t, arg.FromAccountID, transfer.FromAccountID)
require.Equal(t, arg.ToAccountID, transfer.ToAccountID)
}
}
*testing.T
和*testing.M
究竟是什麼?彼此的關係又是什麼?
彼此的關係
: *testing.M
提供了一個環境,讓你可以在測試開始前後執行一些初始化或清理的工作,如設置資料庫連接、啟動或關閉服務等。當你執行 m.Run()
時,Go 會開始執行所有名稱前綴為 Test
的測試函數,且每個這樣的函數都會收到一個 *testing.T
參數。testing.M
:
TestMain
函數中使用,讓你可以在執行測試前後執行一些設置或清理的工作。m.Run()
方法,該方法會執行所有的測試函數。os.Exit()
一起使用)。testing.T
:func TestSomething(t *testing.T) {...}
。t.Error()
, t.Errorf()
, t.Fatal()
, 和 t.Fatalf()
。t.Run()
來執行子測試。所以testing.M
宣告的object都會在m.Run時 傳遞給testing.T
嗎?
*testing.M
和 *testing.T
並不直接將物件傳遞給彼此。全域變數
或其他方法共享狀態或資源。main_test.go
中,testQueries
是一個全域變數。當 TestMain
初始化它時,其他的測試函數(接受 *testing.T
為參數)也可以訪問它。但這並不是 *testing.M
直接將物件傳遞給 *testing.T
的結果,而是因為它們共享了同一個執行環境。Golang 如何宣告全域變數 ?
Package變數:
goCopy code
// ExportedVar 是一個可以被其他套件訪問的變數
var ExportedVar int
// privateVar 只能在當前套件內訪問
var privateVar string
區域變數:
goCopy code
func someFunction() {
// localVar 是一個區域變數,只能在這個函式內部訪問
var localVar int
}
為何testQueries 可以使用CreateAccount這個method呢?
testQueries
是一個指向 Queries
結構的指標。我們從你之前提供的代碼中可以看到,testQueries
被定義為 Queries
。CreateAccount
方法是定義在 Queries
結構上的,因此所有的 Queries
物件(包括指標)都能夠調用此方法。testQueries
是在 main_test.go
中宣告為全域變數,這意味著它可以被 main_test.go
中的其他測試函式訪問和使用,例如在 account_test.go
中的 TestCreateAccount
函式。為何需要random.go
來隨機生成test data呢?